/*
Copyright (c) 2012 Safe Creative (http://www.safecreative.org)

Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
 */
package com.digiregistry.api;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpException;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.URI;
import org.apache.commons.httpclient.methods.*;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.InputSource;

import com.digiregistry.api.util.AuthDigester;
import com.digiregistry.api.util.Digest;
import com.digiregistry.api.util.IOHelper;
import com.digiregistry.api.wrapper.UploadProgressListener;
import com.digiregistry.api.wrapper.util.ProgressFileRequestEntity;

/**
 * DigiRegistry API frontend
 *
 * @author mpolo@safecreative.org
 * @author jguillo@safecreative.org
 */
public class DigiRegistryAPI {

	public static final String CLIENT_VERSION = "1.1";
	public static final String DEFAULT_ENCODING = "UTF-8";
	public static final String API_ENDPOINT = "/";
	public static final String NOT_AUTHORIZED_ERROR = "NotAuthorized";
	public static final String INVALID_TIME_ERROR = "InvalidTime";
	public final static String HEADER_PREFIX = "sc-";
	public final static String SHAREDKEY_HEADER = HEADER_PREFIX + "sharedkey";
	public final static String AUTHKEY_HEADER = HEADER_PREFIX + "authkey";
	public final static String ZTIME_HEADER = HEADER_PREFIX + "ztime";
	public final static String SIGNATURE_HEADER = HEADER_PREFIX + "signature";
	public final static String VERSION_HEADER = HEADER_PREFIX + "version";
	public final static String DEBUG_COMPONENT_HEADER = HEADER_PREFIX + "debug-component";
	public final static String CONTENT_TYPE_HEADER = "Content-Type";
	public final static String CONTENT_LENGTH_HEADER = "Content-Length";
	public final static String CONTENT_MD5_HEADER = "Content-MD5";
	private static Logger log = LoggerFactory.getLogger(DigiRegistryAPI.class);
	private String baseUrl;
	private Long timeOffset;
	private XPathFactory xpathFactory;
	private String sharedKey, privateKey;
	private HttpClient httpClient;

	public enum ApiMethod {

		GET, PUT, POST, DELETE
	}

	public DigiRegistryAPI(String sharedKey, String privateKey) {
		this.sharedKey = sharedKey;
		this.privateKey = privateKey;
		this.xpathFactory = XPathFactory.newInstance();
		this.httpClient = new HttpClient();
		setTimeout(30000);
		setHttpClientParameter("http.protocol.max-redirects", 5);
		setHttpClientParameter("http.useragent", getClass().getSimpleName() + "/" + CLIENT_VERSION);
	}

	public void setBaseUrl(String baseUrl) {
		this.baseUrl = baseUrl;
	}

	public String getBaseUrl() {
		return baseUrl;
	}

	public String getSharedKey() {
		return sharedKey;
	}

	public String getPrivateKey() {
		return privateKey;
	}

	public final int getTimeout() {
		return (Integer) httpClient.getParams().getParameter("http.socket.timeout");
	}

	public final void setTimeout(int socketTimeout) {
		setHttpClientParameter("http.socket.timeout", socketTimeout);
	}

	public String getZTime() {
		if (timeOffset == null) {
			log.debug("Building time offset ztime");
			String result = callGet("ztime", false);
			String value = evalXml(result, "/ztime");
			if (value == null) {
				throw new RuntimeException("bad ztime");
			}
			long ztime = Long.parseLong(value);
			timeOffset = ztime - getSystemTime();
			log.debug("ztime={} timeOffset={} systime={}", new Object[]{ztime, timeOffset, ztime - timeOffset});
			return value;
		}
		long ztime = timeOffset + getSystemTime();
		log.debug("ztime={} timeOffset={} systime={}", new Object[]{ztime, timeOffset, ztime - timeOffset});
		return Long.toString(ztime);
	}

	public String callGet(String path) {
		return callGet(path, true);
	}

	public String callGet(String path, boolean signed) {
		return call(ApiMethod.GET, path, null, signed);
	}

	public String callGet(String path, String authKey, String privateKey) {
		return call(ApiMethod.GET, path, null, authKey, privateKey);
	}

	public String callPut(String path, String contents) {
		return call(ApiMethod.PUT, path, contents, true);
	}

	public String callPut(String path, String contents, String authKey, String privateKey) {
		return call(ApiMethod.PUT, path, contents, authKey, privateKey);
	}

	public String callPost(String path, String contents) {
		return callPost(path, contents, true);
	}

	public String callPost(String path, String contents, boolean signed) {
		return call(ApiMethod.POST, path, contents, signed);
	}

	public String callPost(String path, String contents, String authKey, String privateKey) {
		return call(ApiMethod.POST, path, contents, authKey, privateKey);
	}

	public String callDelete(String path) {
		return call(ApiMethod.DELETE, path, null, true);
	}

	public String callDelete(String path, String authKey, String privateKey) {
		return call(ApiMethod.DELETE, path, null, authKey, privateKey);
	}

	public String call(ApiMethod method, String path, String content, boolean signed) {
		return call(method, path, content, null, signed ? privateKey : null);
	}

	public String call(ApiMethod method, String path, String content, String authKey, String privateKey) {
		return call(method, path, content, authKey, privateKey, null);
	}

	public String call(ApiMethod method, String path, String content,
			String authKey, String privateKey,
			String debugComponent) {
		String uri = baseUrl + API_ENDPOINT + path;
		String response = null;
		HttpMethod httpMethod = null;
		Map<String, String> headers = new LinkedHashMap<String, String>();
		try {
			log.debug(String.format("Configuring request:\n%s %s", method.name(), uri));
			boolean requiresContent;
			switch (method) {
				case GET:
					httpMethod = new GetMethod(uri);
					requiresContent = false;
					break;
				case PUT:
					httpMethod = new PutMethod(uri);
					requiresContent = true;
					break;
				case POST:
					httpMethod = new PostMethod(uri);
					requiresContent = true;
					break;
				case DELETE:
					httpMethod = new DeleteMethod(uri);
					requiresContent = false;
					break;
				default:
					throw new RuntimeException("Method not allowed");
			}
			if (requiresContent && content == null) {
				throw new RuntimeException("Must provide content for PUT and POST methods");
			}
			if (requiresContent) {
				byte[] contentBytes = content.getBytes(DEFAULT_ENCODING);
				EntityEnclosingMethod eem = (EntityEnclosingMethod) httpMethod;
				eem.setRequestEntity(new ByteArrayRequestEntity(contentBytes));
				headers.put(CONTENT_TYPE_HEADER, "text/xml;charset=" + DEFAULT_ENCODING);
				headers.put(CONTENT_LENGTH_HEADER, String.valueOf(contentBytes.length));
				headers.put(CONTENT_MD5_HEADER, Digest.getHexDigest(contentBytes, Digest.MD5));
			}
			if (debugComponent != null) {
				headers.put(DEBUG_COMPONENT_HEADER, debugComponent);
			}

			headers.put(SHAREDKEY_HEADER, sharedKey);

			if (!path.startsWith("ztime")) {
				headers.put(ZTIME_HEADER, getZTime());
			}

			if (authKey != null) {
				headers.put(AUTHKEY_HEADER, authKey);
			}
			if (privateKey != null) {
				String signature = getSignature(method, path, headers, privateKey);
				headers.put(SIGNATURE_HEADER, signature);
			}

			for (Map.Entry<String, String> header : headers.entrySet()) {
				httpMethod.addRequestHeader(header.getKey(), header.getValue());
			}

			log.debug(String.format("\nAPI REQUEST: \n%s %s\n%s\n%s\n",
					method, uri,
					headersToString(headers),
					content == null ? "<NO-CONTENT>" : content));

			int httpResult = httpClient.executeMethod(httpMethod);

			response = readString(httpMethod.getResponseBodyAsStream());

			log.debug(String.format("\nAPI RESPONSE: %d\n %s\n\n", httpResult, response));
			if (isError(response) && INVALID_TIME_ERROR.equals(getErrorCode(response))) {
				log.warn("Client time needs resyncing");
				timeOffset = null;
			}
			return response;
		} catch (Throwable e) {
			throw new RuntimeException(ApiException.wrap(e, uri + ":" + headers + ":" + content, response));
		} finally {
			if (httpMethod != null) {
				httpMethod.releaseConnection();
			}
		}
	}

	public void _uploadFile(java.io.File f, String url, String contentType, UploadProgressListener listener) throws HttpException, IOException {
		PutMethod method = new PutMethod(url);
		method.setRequestEntity(new ProgressFileRequestEntity(f, contentType, listener));
		executeHttpMethod(method);
	}

	public void _uploadText(String text, String url, String contentType, String charset) throws HttpException, IOException {
		PutMethod method = new PutMethod(url);
		method.setRequestEntity(new StringRequestEntity(text, contentType, charset));
		executeHttpMethod(method);
	}

	protected final void setHttpClientParameter(String parameter, Object value) {
		httpClient.getParams().setParameter(parameter, value);
	}

	private void executeHttpMethod(HttpMethod method) throws IOException {
		try {
			int status = httpClient.executeMethod(method);
			if (status == HttpStatus.SC_TEMPORARY_REDIRECT) {
				Header locationHeader = method.getResponseHeader("location");
				if (locationHeader != null) {
					String location = locationHeader.getValue();
					method.setURI(new URI(location, true));
					executeHttpMethod(method);
				}
			}
			if (status != HttpStatus.SC_OK) {
				throw new HttpException("HTTP error " + status);
			}
		} finally {
			method.releaseConnection();
		}
	}

	private String getSignature(ApiMethod method, String path, Map<String, String> headers, String privateKey) {
		AuthDigester digester = new AuthDigester(privateKey);

		digester.update(method.name());
		digester.update("\n");

		digester.update(path);
		digester.update("\n");

		ArrayList<String> scKeys = new ArrayList<String>();
		for (String key : headers.keySet()) {
			if (key.startsWith(HEADER_PREFIX)
					&& !DEBUG_COMPONENT_HEADER.equals(key)) {
				scKeys.add(key);
			}
		}
		Collections.sort(scKeys);
		for (String key : scKeys) {
			digester.update(key);
			digester.update("=");
			digester.update(headers.get(key));
			digester.update("\n");
		}

		if (headers.containsKey(CONTENT_MD5_HEADER)) {
			digester.update(CONTENT_LENGTH_HEADER);
			digester.update("=");
			digester.update(headers.get(CONTENT_LENGTH_HEADER));
			digester.update("\n");

			digester.update(CONTENT_MD5_HEADER);
			digester.update("=");
			digester.update(headers.get(CONTENT_MD5_HEADER));
			digester.update("\n");
		}

		return digester.digest();
	}

	public String getResponseState(String response) throws Exception {
		return getResponseState("restvalueresponse", response);
	}

	public String getResponseState(String responseType, String response) throws Exception {
		return evalXml(response, String.format("/%s/state", responseType));
	}

	public boolean isError(String result) {
		return !StringUtils.isEmpty(result) && ((hasXmlElement(result, "error") && hasXmlElement(result, "errorId"))
				|| (hasXmlElement(result, "exception") && hasXmlElement(result, "exceptionId")));
	}

	public String getErrorCode(String response) {
		if (response.indexOf("error") != -1) {
			return evalXml(response, "/error/errorId");
		}
		return evalXml(response, "/exception/exceptionId");
	}

	public String getErrorMessage(String response) {
		if (response.indexOf("error") != -1) {
			return evalXml(response, "/error/errorMessage");
		}
		return evalXml(response, "/exception/exceptionMessage");
	}

	public String evalXml(String xml, String path) {
		XPath xpath = xpathFactory.newXPath();
		InputSource source = new InputSource(new StringReader(xml));
		String result = null;
		try {
			result = xpath.evaluate(path, source);
		} catch (XPathExpressionException ex) {
		}
		return result;
	}

	/**
	 * Fast xml element check
	 * @param response xml response
	 * @param element element name to find
	 * @return <code>true</code> id a pair of xml element tags is withing the xml response
	 */
	public boolean hasXmlElement(String response, String element) {
		String beginElement = "<" + element + ">";
		String endElement = "</" + element + ">";
		int beginIdx = response.indexOf(beginElement);
		int endIdx = response.indexOf(endElement);
		return beginIdx >= 0 && beginIdx < endIdx;
	}

	protected String readString(InputStream in) throws IOException {
		return IOHelper.readString(in, DEFAULT_ENCODING);
	}

	private long getSystemTime() {
		return System.currentTimeMillis();
	}

	private String headersToString(Map<String, String> headers) {
		StringBuilder sb = new StringBuilder();
		for (Map.Entry<String, String> entry : headers.entrySet()) {
			sb.append(entry.getKey());
			sb.append("=");
			sb.append(entry.getValue());
			sb.append("\n");
		}
		return sb.toString();
	}
}